외래 키가 데이터 정합성을 지키는 방식

외래 키가 데이터 정합성을 지키는 방식

한눈에 보기

FOREIGN KEY는 자식 값이 부모 키를 참조하도록 제한한다. 삭제 정책은 RESTRICT, CASCADE, SET NULL 중 데이터 생명주기에 맞게 선택한다.

목차

문제가 되는 상황

주문을 생성하기 전에 고객이 존재하는지 SELECT로 확인했다고 하자. 확인 직후 다른 transaction이 고객을 삭제하고, 첫 transaction이 주문을 insert하면 존재하지 않는 고객을 가리키는 주문이 남을 수 있다. 두 문장이 별도 동작이기 때문에 그 사이 상태가 바뀔 수 있다.

외래 키는 자식의 참조 값이 부모의 candidate key에 존재해야 한다는 규칙을 DB 쓰기 경계에서 보장한다. API, batch, migration script, 운영 SQL처럼 어떤 경로로 쓰더라도 같은 규칙이 적용된다. 대신 삭제 생명주기와 lock·index 비용을 명확히 설계해야 한다.

이 글의 예제에 관하여

고객·주문·주문 항목 schema는 외래 키 동작을 설명하기 위한 가상 예제다. 실제 고객이나 주문 데이터를 사용하지 않았다.

애플리케이션의 존재 확인만으로 부족한 이유

다음 코드는 단일 요청만 보면 자연스럽다.

async function createOrder(customerId: string, command: CreateOrderCommand) {
  const customer = await customerRepository.findById(customerId);
  if (!customer) throw new Error("CUSTOMER_NOT_FOUND");

  return orderRepository.insert({ customerId, ...command });
}

동시 실행에서는 race가 생긴다.

sequenceDiagram
    participant A as Transaction A
    participant D as Database
    participant B as Transaction B
    A->>D: customer 42 존재 확인
    D-->>A: 존재함
    B->>D: customer 42 삭제 후 commit
    A->>D: customer_id 42 주문 insert
    Note over A,D: 외래 키가 없으면 고아 주문 가능

애플리케이션 check는 친절한 오류 메시지와 업무 조건 검증에 유용하다. 그러나 최종 정합성은 같은 DB transaction과 constraint가 담당해야 한다.

외래 키가 보장하는 관계

CREATE TABLE customers (
  id BIGINT PRIMARY KEY,
  name VARCHAR(100) NOT NULL
);

CREATE TABLE orders (
  id BIGINT PRIMARY KEY,
  customer_id BIGINT NOT NULL,
  status VARCHAR(20) NOT NULL,
  CONSTRAINT fk_orders_customer
    FOREIGN KEY (customer_id)
    REFERENCES customers(id)
    ON DELETE RESTRICT
);

DB는 다음 쓰기를 검사한다.

참조 대상은 primary key뿐 아니라 DB가 허용하는 unique candidate key일 수 있다. 부모와 자식 컬럼의 type, 길이, collation, signed 여부가 호환되어야 한다.

nullable 외래 키는 “관계가 아직 없을 수 있음”을 표현한다.

assigned_agent_id BIGINT NULL

0, 빈 문자열, 존재하지 않는 dummy ID로 “없음”을 표현하면 별도 가짜 부모 행과 예외 규칙이 생긴다.

RESTRICT, CASCADE, SET NULL 선택

삭제 정책 부모 삭제 시 동작 적합할 수 있는 관계
RESTRICT 자식이 있으면 부모 삭제 거부 주문이 있는 고객의 물리 삭제 금지
CASCADE 관련 자식도 삭제 주문에 완전히 소유된 임시 항목
SET NULL 자식 FK를 NULL로 변경 담당자 삭제 후 기록은 유지
NO ACTION DB와 시점에 따라 제약 검사 엔진 semantics 확인 필요
CREATE TABLE order_items (
  order_id BIGINT NOT NULL,
  line_no SMALLINT NOT NULL,
  PRIMARY KEY (order_id, line_no),
  CONSTRAINT fk_order_items_order
    FOREIGN KEY (order_id)
    REFERENCES orders(id)
    ON DELETE CASCADE
);

주문 항목이 주문 없이는 의미가 없고 주문을 물리 삭제하는 도메인이라면 cascade가 자연스러울 수 있다. 그러나 결제·감사 기록까지 연쇄 cascade가 이어지면 한 번의 delete가 예상보다 넓은 데이터를 제거한다.

CASCADE를 편의 기능으로 선택하지 않는다

“자식부터 지우는 코드가 귀찮다”가 아니라 자식의 생명주기가 부모에 완전히 소유되는지, 삭제 복구와 감사 요구가 무엇인지로 결정한다.

부모와 자식의 생명주기로 판단한다

관계 유형을 다음 질문으로 본다.

  1. 부모 없이 자식이 존재할 수 있는가?
  2. 부모 삭제는 물리 삭제인가, 비활성화인가?
  3. 자식이 법적·감사 기록으로 남아야 하는가?
  4. 부모 key가 변경될 수 있는가?
  5. 자식 관계가 다른 부모로 이동할 수 있는가?

사용자 계정 삭제 후에도 결제 기록을 보존해야 한다면 user→payment에 cascade delete를 사용하면 안 된다. 사용자 개인정보를 익명화하고 payment의 내부 subject reference를 유지하는 별도 정책이 필요할 수 있다.

ON UPDATE CASCADE는 자연 키 변경을 전파할 수 있지만, 많은 자식 행을 갱신하고 lock을 만들 수 있다. 변경 가능한 업무 값을 primary/foreign key로 사용한 설계가 맞는지 먼저 검토한다.

외래 키 컬럼의 인덱스

부모를 삭제하거나 key를 변경할 때 DB는 참조하는 자식 행을 찾아야 한다. 자식 외래 키 컬럼에 적절한 index가 없으면 큰 table을 scan하거나 lock 범위가 커질 수 있다.

CREATE INDEX idx_orders_customer_id
ON orders (customer_id);

다만 이미 (customer_id, created_at) 복합 index가 있다면 왼쪽 접두로 FK lookup을 지원할 수 있어 단일 index가 중복일 수 있다. 실제 DB가 FK index를 자동 생성하는지, 기존 index를 사용할 수 있는지 확인한다.

복합 외래 키는 컬럼 순서와 type이 참조 key에 맞아야 한다.

FOREIGN KEY (order_id, line_no)
REFERENCES order_items(order_id, line_no)

index는 constraint 검증뿐 아니라 일반 join query에도 영향을 준다. 쓰기 비용까지 고려해 중복 index를 피한다.

동시 쓰기와 lock 영향

외래 키 검사는 공짜가 아니다. 자식 insert 중 부모가 사라지지 않도록 DB가 부모 key에 필요한 lock을 잡고, 부모 delete는 자식 참조를 확인한다. transaction이 길면 예상하지 못한 대기가 생길 수 있다.

Transaction A: customer 42에 주문 insert, 오래 열린 transaction
Transaction B: customer 42 삭제 시도 → 대기 또는 실패

다른 순서로 여러 parent를 갱신하면 deadlock 가능성도 있다. 애플리케이션은 deadlock을 일시적 transaction 실패로 처리하고 멱등한 범위에서 재시도한다.

대량 delete를 cascade 한 문장으로 수행하면 긴 transaction, replication lag, undo log 증가가 생길 수 있다. 작은 batch와 명시적인 archival·deletion workflow를 검토한다.

soft delete에서는 별도 규칙이 필요하다

외래 키는 부모 행이 물리적으로 존재하는지만 안다. deleted_at이 채워진 고객을 새 주문이 참조하는 것을 기본 FK가 막지는 않는다.

CREATE TABLE customers (
  id BIGINT PRIMARY KEY,
  deleted_at DATETIME NULL
);

애플리케이션은 활성 상태를 업무 규칙으로 검증하고, 필요한 경우 transaction lock이나 상태 전이 조건부 update로 동시성을 다룬다.

SELECT id
FROM customers
WHERE id = :customer_id
  AND deleted_at IS NULL
FOR UPDATE;

soft delete와 주문 생성이 동시에 들어올 때 어느 쪽이 이겨야 하는지 transaction 순서와 lock 정책을 정한다. FK 하나로 모든 도메인 상태 규칙을 표현하려 하지 않는다.

기존 테이블에 외래 키 추가하기

운영 테이블에 constraint를 바로 추가하면 기존 고아 행 때문에 실패하거나 큰 검증 scan과 lock이 발생할 수 있다.

먼저 고아 데이터를 찾는다.

SELECT o.id, o.customer_id
FROM orders o
LEFT JOIN customers c ON c.id = o.customer_id
WHERE c.id IS NULL
LIMIT 100;

진행 단계는 다음과 같다.

  1. 새 쓰기 경로가 고아를 만들지 않도록 먼저 수정한다.
  2. 기존 고아 행의 원인과 처리 정책을 정한다.
  3. 자식 FK index를 준비한다.
  4. 작은 batch로 데이터를 수정·격리한다.
  5. DB의 online validation·not-valid constraint 지원을 확인한다.
  6. replication lag와 lock 시간을 관찰하며 constraint를 검증한다.
  7. 위반 지표와 rollback runbook을 준비한다.

고아 주문을 임의의 “unknown customer”에 모두 붙이면 업무 의미가 왜곡될 수 있다. 삭제·복구·격리 중 도메인 owner와 정책을 정한다.

서비스와 DB 경계를 넘는 관계

서로 다른 database 또는 microservice의 table 사이에는 일반적인 DB 외래 키를 걸 수 없다.

Orders DB: customer_id
Customers DB: customers.id

이 관계는 다음 수단으로 관리할 수 있다.

네트워크 확인 뒤 상태가 바뀌는 race는 여전히 있다. 서비스 경계를 나누는 순간 강한 FK 보장을 포기하고 eventual consistency와 복구 workflow를 운영하게 된다는 비용을 인식해야 한다. 같은 database 안의 module이라면 단지 “나중에 분리할 수 있다”는 이유로 FK를 제거하지 않는다.

실전 점검 목록

외래 키 설계

  • 애플리케이션 선조회와 DB constraint를 함께 사용하는가?
  • 부모·자식 생명주기에 맞춰 delete 정책을 선택했는가?
  • cascade 범위와 복구·감사 요구를 검토했는가?
  • FK lookup을 지원하는 중복되지 않은 index가 있는가?
  • 긴 transaction과 대량 cascade의 lock 비용을 측정했는가?
  • soft-deleted 부모 참조를 별도 업무 규칙으로 막는가?
  • 기존 고아 데이터 정리와 online validation 계획이 있는가?
  • DB 경계를 넘는 관계에 reconciliation과 삭제 protocol이 있는가?

FOREIGN KEY는 자식 값이 부모 키를 참조하도록 제한한다. 삭제 정책은 RESTRICT, CASCADE, SET NULL 중 데이터 생명주기에 맞게 선택한다.

결론

외래 키는 모든 DB 쓰기 경로에서 자식의 참조 값이 유효한 부모 key를 가리키도록 보장해 애플리케이션 선조회의 race를 막는다. 삭제 정책은 부모·자식 생명주기로 결정하고, FK index·lock·대량 cascade 비용을 측정해야 한다. soft delete 상태와 서비스 경계를 넘는 참조는 기본 FK가 해결하지 못하므로 별도 transaction 규칙과 reconciliation을 설계해야 한다.

관련 노트